feat(config): implement global config to wire up config command#1789
feat(config): implement global config to wire up config command#1789Hweinstock wants to merge 8 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## refactor #1789 +/- ##
============================================
+ Coverage 93.91% 94.04% +0.13%
============================================
Files 118 123 +5
Lines 6423 6621 +198
============================================
+ Hits 6032 6227 +195
- Misses 391 394 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
db54dba to
b39f26a
Compare
|
typecheck fixed in #1756 (comment) |
b39f26a to
4b819e1
Compare
d60cf0d to
7fdb445
Compare
|
rebased onto create command work. |
AlexanderRichey
left a comment
There was a problem hiding this comment.
I feel that the level of complexity this PR introduces is out of proportion with the functionality gained. Let's sync on this and see if we can develop a simpler approach.
|
I have some prior art here that might be useful to look at: https://github.com/AlexanderRichey/yagss/blob/main/internal/builder/config.go |
|
converted to draft while I rework based on offline discussion from above, will take out of draft when ready for another review. |
c9c3f17 to
c0c3d7a
Compare
1ab82be to
3d8bebb
Compare
|
Reworked the implementation to simplify:
|
|
taking back into draft to address some comments on #1794, that are relevant here as well. Specifically,
|
There was a problem hiding this comment.
This is a nice improvement over the initial rev, but I still think there's a lot of room to simplify. Let me sketch what I have in mind.
First, ReadWriteJson needs to become generic to justify itself as an interface. The current JsonDataSource is way too specific—and it's name is a bit confusing since it can only be used to read and write one file. Here's an implementation, thanks to Claude, that should handle everything we need to.
import { readFileSync, writeFileSync } from "node:fs";
import { z } from "zod";
class DeserializationError extends Error {
constructor(path: string, options?: { cause?: unknown }) {
super(`Failed to deserialize JSON at "${path}"`, options);
this.name = "DeserializationError";
}
}
interface ReadWriteJson {
read<T>(path: string, schema: z.ZodType<T>): T;
write<T>(path: string, data: T): void;
}
class JsonFileStore implements ReadWriteJson {
read<T>(path: string, schema: z.ZodType<T>): T {
const contents = readFileSync(path, "utf-8");
let parsed: unknown;
try {
parsed = JSON.parse(contents);
} catch (cause) {
// The file wasn't valid JSON.
throw new DeserializationError(path, { cause });
}
const result = schema.safeParse(parsed);
if (!result.success) {
// The file was valid JSON but didn't match the shape of T.
throw new DeserializationError(path, { cause: result.error });
}
return result.data;
}
write<T>(path: string, data: T): void {
const contents = JSON.stringify(data, null, 2);
writeFileSync(path, contents, "utf-8");
}
}With this, the implementation of GlobalConfigAccessor should become straightforward.
// ---------------------------------------------------------------------------
// Global config schema and types
// ---------------------------------------------------------------------------
export const globalConfigSchema = z.object({
telemetry: z
.object({
enabled: z.boolean().optional(),
endpoint: z.string().optional(),
audit: z.boolean().optional(),
})
.optional(),
installationId: z.uuid().optional(),
});
/** Inferred shape of the global config from {@link globalConfigSchema}. */
export type GlobalConfig = z.infer<typeof globalConfigSchema>;
/** Reads and writes global CLI configuration. */
export interface GlobalConfigAccessor {
/** Returns the current global config. */
get(): Promise<GlobalConfig>;
/** Validates and persists a new config. Throws on invalid shape. */
set(updated: GlobalConfig): Promise<GlobalConfig>;
}
// ---------------------------------------------------------------------------
// Accessor implementation
// ---------------------------------------------------------------------------
export class DefaultGlobalConfigAccessor implements GlobalConfigAccessor {
constructor(
private readonly store: ReadWriteJson,
private readonly path: string,
) {}
async get(): Promise<GlobalConfig> {
try {
return this.store.read(this.path, globalConfigSchema);
} catch (err) {
if (isFileNotFound(err)) {
// No config yet (first run, fresh install). An empty object is a
// valid GlobalConfig because every field in the schema is optional.
// Or throw some error here.
return {};
}
throw err;
}
}
async set(updated: GlobalConfig): Promise<GlobalConfig> {
// Even though `updated` is typed as GlobalConfig, we re-validate at
// runtime: the type is only a compile-time guarantee, and callers can
// defeat it (e.g. a value cast with `as`, or data that originated as
// `unknown`). parse() throws a ZodError on invalid shape, satisfying
// the interface's "throws on invalid shape" contract.
const validated = globalConfigSchema.parse(updated);
this.store.write(this.path, validated);
return validated;
}
}
function isFileNotFound(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code?: unknown }).code === "ENOENT"
);
}Now, all you'd have to do is wire this stuff up in the handler chain using DI.
const store = new JsonFileStore();
const accessor = new DefaultGlobalConfigAccessor(store, "/path/to/config.json");Note: We might want to adjust ReadWriteJson so that it's async, which would be more consistent with the whole codebase.
23f6a98 to
0c54fd8
Compare
|
Aligned implementation closer with proposal. swapped to async, changed some names, and swapped the |
d73b36d to
e393ae5
Compare
e393ae5 to
548979d
Compare
Problem
The CLI is missing a way to view and control global configuration. There is an existing
configcommand, but its not wired up.Solution
globalConfig/module that defines an accessor for global config. The global config is decoupled from the source of the data via an injected dependency.src/router/schemas.tsxto top level/parsingdirectory so that logic can be re-used.telemetry,telemetry.enabled, etc. All of which are derived automatically from the schema definition.Testing
Ex.
Future Work